/*
 * Copyright 2017-2021 original authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.micronaut.jackson;

import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.core.annotation.TypeHint;
import io.micronaut.core.type.Argument;
import io.micronaut.core.util.ArgumentUtils;
import io.micronaut.core.util.ArrayUtils;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.json.JsonConfiguration;
import org.jspecify.annotations.NonNull;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.core.StreamWriteFeature;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.core.json.JsonWriteFeature;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.PropertyNamingStrategy;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.cfg.EnumFeature;
import tools.jackson.databind.cfg.JsonNodeFeature;
import tools.jackson.databind.type.TypeFactory;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;

/**
 * Configuration for the Jackson JSON parser.
 *
 * @author Graeme Rocher
 * @since 1.0
 */
@ConfigurationProperties("jackson")
@TypeHint(
    value = {
        PropertyNamingStrategies.UpperCamelCaseStrategy.class,
        ArrayList.class,
        LinkedHashMap.class,
        HashSet.class
    })
public class JacksonConfiguration implements JsonConfiguration {

    /**
     * The default array size threshold value.
     */
    @SuppressWarnings("WeakerAccess")
    public static final int DEFAULT_ARRAYSIZETHRESHOLD = 100;
    /**
     * The property used to enable module scan.
     */
    public static final String PROPERTY_MODULE_SCAN = "jackson.module-scan";

    private boolean moduleScan = true;
    private String dateFormat;
    private Locale locale;
    private TimeZone timeZone;
    private int arraySizeThreshold = DEFAULT_ARRAYSIZETHRESHOLD;
    private Map<SerializationFeature, Boolean> serializationFeatures = Collections.emptyMap();
    private Map<DeserializationFeature, Boolean> deserializationFeatures = Collections.emptyMap();
    private Map<MapperFeature, Boolean> mapperFeatures = Collections.emptyMap();
    private Map<JsonReadFeature, Boolean> jsonReadFeatures = Collections.emptyMap();
    private Map<JsonWriteFeature, Boolean> jsonWriteFeatures = Collections.emptyMap();
    private Map<JsonFactory.Feature, Boolean> jsonFactoryFeatures = Collections.emptyMap();
    private Map<StreamReadFeature, Boolean> streamReadFeatures = Collections.emptyMap();
    private Map<StreamWriteFeature, Boolean> streamWriteFeatures = Collections.emptyMap();
    private Map<EnumFeature, Boolean> enumFeatures = Collections.emptyMap();
    private Map<DateTimeFeature, Boolean> dateTimeFeatures = Collections.emptyMap();
    private Map<JsonNodeFeature, Boolean> jsonNodeFeatures = Collections.emptyMap();
    private JsonInclude.Include serializationInclusion = JsonInclude.Include.NON_EMPTY;
    private DefaultTyping defaultTyping = null;
    private PropertyNamingStrategy propertyNamingStrategy = null;
    private boolean alwaysSerializeErrorsAsList = true;
    private boolean trimStrings = false;

    /**
     * Whether Jackson modules should be scanned for.
     *
     * @return True if module scanning is enabled
     */
    public boolean isModuleScan() {
        return moduleScan;
    }

    /**
     * Sets whether to scan for modules or not (defaults to true).
     *
     * @param moduleScan True if module scan should be enabled
     */
    public void setModuleScan(boolean moduleScan) {
        this.moduleScan = moduleScan;
    }

    /**
     * @return The default serialization inclusion settings
     */
    public JsonInclude.Include getSerializationInclusion() {
        return serializationInclusion;
    }

    /**
     * @return The global defaultTyping using for Polymorphic handling
     */
    public DefaultTyping getDefaultTyping() {
        return defaultTyping;
    }

    /**
     * @return The default locale to use
     */
    public Locale getLocale() {
        return locale;
    }

    /**
     * @return The default time zone to use
     */
    public TimeZone getTimeZone() {
        return timeZone;
    }

    /**
     * @return The date format to use for dates
     */
    public String getDateFormat() {
        return dateFormat;
    }

    /**
     * @return The array size threshold to use when using Jackson for data binding
     */
    @Override
    public int getArraySizeThreshold() {
        return arraySizeThreshold;
    }

    /**
     * @return The property naming strategy
     */
    public PropertyNamingStrategy getPropertyNamingStrategy() {
        return propertyNamingStrategy;
    }

    /**
     * Whether _embedded.errors should always be serialized as list. If set to false, _embedded.errors
     * with 1 element will be serialized as an object.
     *
     * @return True if _embedded.errors should always be serialized as list.
     */
    @Override
    public boolean isAlwaysSerializeErrorsAsList() {
        return alwaysSerializeErrorsAsList;
    }

    /**
     * Whether strings should be trimmed when deserializing. If the resulting string is an empty string,
     * then null will be applied instead.
     *
     * @return True if strings should be trimmed when deserializing.
     */
    public boolean isTrimStrings() {
        return trimStrings;
    }

    /**
     * Sets the default date format to use.
     *
     * @param dateFormat The date format
     */
    public void setDateFormat(String dateFormat) {
        this.dateFormat = dateFormat;
    }

    /**
     * Sets the locale to use.
     *
     * @param locale The locale
     */
    public void setLocale(Locale locale) {
        this.locale = locale;
    }

    /**
     * Sets the timezone to use.
     *
     * @param timeZone The timezone
     */
    public void setTimeZone(TimeZone timeZone) {
        this.timeZone = timeZone;
    }

    /**
     * Sets the array size threshold for data binding. Default value ({@value #DEFAULT_ARRAYSIZETHRESHOLD}).
     *
     * @param arraySizeThreshold The array size threshold
     */
    public void setArraySizeThreshold(int arraySizeThreshold) {
        this.arraySizeThreshold = arraySizeThreshold;
    }

    /**
     * Sets the serialization features to use.
     *
     * @param serializationFeatures The serialization features.
     */
    public void setSerializationFeatures(Map<SerializationFeature, Boolean> serializationFeatures) {
        if (CollectionUtils.isNotEmpty(serializationFeatures)) {
            this.serializationFeatures = serializationFeatures;
        }
    }

    /**
     * Sets the deserialization features to use.
     *
     * @param deserializationFeatures The deserialiation features.
     */
    public void setDeserializationFeatures(Map<DeserializationFeature, Boolean> deserializationFeatures) {
        if (CollectionUtils.isNotEmpty(deserializationFeatures)) {
            this.deserializationFeatures = deserializationFeatures;
        }
    }

    /**
     * Sets the object mapper features to use.
     *
     * @param mapperFeatures The object mapper features
     */
    public void setMapperFeatures(Map<MapperFeature, Boolean> mapperFeatures) {
        if (CollectionUtils.isNotEmpty(mapperFeatures)) {
            this.mapperFeatures = mapperFeatures;
        }
    }

    /**
     * Format-independent stream write features.
     *
     * @return The stream write features
     */
    public Map<StreamWriteFeature, Boolean> getStreamWriteFeatures() {
        return streamWriteFeatures;
    }

    /**
     * Format-independent stream write features.
     *
     * @param streamWriteFeatures The stream write features
     */
    public void setStreamWriteFeatures(Map<StreamWriteFeature, Boolean> streamWriteFeatures) {
        this.streamWriteFeatures = streamWriteFeatures;
    }

    /**
     * Format-independent stream read features.
     *
     * @return The stream read features
     */
    public Map<StreamReadFeature, Boolean> getStreamReadFeatures() {
        return streamReadFeatures;
    }

    /**
     * Format-independent stream read features.
     *
     * @param streamReadFeatures The stream read features
     */
    public void setStreamReadFeatures(Map<StreamReadFeature, Boolean> streamReadFeatures) {
        this.streamReadFeatures = streamReadFeatures;
    }

    /**
     * JSON factory features.
     *
     * @return JSON factory features
     */
    public Map<JsonFactory.Feature, Boolean> getJsonFactoryFeatures() {
        return jsonFactoryFeatures;
    }

    /**
     * JSON factory features.
     *
     * @param jsonFactoryFeatures JSON factory features
     */
    public void setJsonFactoryFeatures(Map<JsonFactory.Feature, Boolean> jsonFactoryFeatures) {
        this.jsonFactoryFeatures = jsonFactoryFeatures;
    }

    /**
     * JSON stream write features.
     *
     * @return JSON stream write features
     */
    public Map<JsonWriteFeature, Boolean> getJsonWriteFeatures() {
        return jsonWriteFeatures;
    }

    /**
     * JSON stream write features.
     *
     * @param jsonWriteFeatures JSON stream write features
     */
    public void setJsonWriteFeatures(Map<JsonWriteFeature, Boolean> jsonWriteFeatures) {
        this.jsonWriteFeatures = jsonWriteFeatures;
    }

    /**
     * JSON stream read features.
     *
     * @return JSON stream read features
     */
    public Map<JsonReadFeature, Boolean> getJsonReadFeatures() {
        return jsonReadFeatures;
    }

    /**
     * JSON stream read features.
     *
     * @param jsonReadFeatures JSON stream read features
     */
    public void setJsonReadFeatures(Map<JsonReadFeature, Boolean> jsonReadFeatures) {
        this.jsonReadFeatures = jsonReadFeatures;
    }

    /**
     * General mapper features.
     *
     * @return General mapper features
     */
    public Map<MapperFeature, Boolean> getMapperFeatures() {
        return mapperFeatures;
    }

    /**
     * General deserialization features.
     *
     * @return General deserialization features
     */
    public Map<DeserializationFeature, Boolean> getDeserializationFeatures() {
        return deserializationFeatures;
    }

    /**
     * General serialization features.
     *
     * @return General serialization features
     */
    public Map<SerializationFeature, Boolean> getSerializationFeatures() {
        return serializationFeatures;
    }

    /**
     * Enum data binding features.
     *
     * @return Enum data binding features
     */
    public Map<EnumFeature, Boolean> getEnumFeatures() {
        return enumFeatures;
    }

    /**
     * Enum data binding features.
     *
     * @param enumFeatures Enum data binding features
     */
    public void setEnumFeatures(Map<EnumFeature, Boolean> enumFeatures) {
        this.enumFeatures = enumFeatures;
    }

    /**
     * Date/time data binding features.
     *
     * @return Date/time data binding features
     */
    public Map<DateTimeFeature, Boolean> getDateTimeFeatures() {
        return dateTimeFeatures;
    }

    /**
     * Date/time data binding features.
     *
     * @param dateTimeFeatures Date/time data binding features
     */
    public void setDateTimeFeatures(Map<DateTimeFeature, Boolean> dateTimeFeatures) {
        this.dateTimeFeatures = dateTimeFeatures;
    }

    /**
     * JsonNode data binding features.
     *
     * @return JsonNode data binding features
     */
    public Map<JsonNodeFeature, Boolean> getJsonNodeFeatures() {
        return jsonNodeFeatures;
    }

    /**
     * JsonNode data binding features.
     *
     * @param jsonNodeFeatures JsonNode data binding features
     */
    public void setJsonNodeFeatures(Map<JsonNodeFeature, Boolean> jsonNodeFeatures) {
        this.jsonNodeFeatures = jsonNodeFeatures;
    }

    /**
     * Sets the serialization inclusion mode.
     *
     * @param serializationInclusion The serialization inclusion mode
     */
    public void setSerializationInclusion(JsonInclude.Include serializationInclusion) {
        if (serializationInclusion != null) {
            this.serializationInclusion = serializationInclusion;
        }
    }

    /**
     * Sets the global defaultTyping using for Polymorphic handling.
     *
     * @param defaultTyping The defaultTyping
     */
    public void setDefaultTyping(DefaultTyping defaultTyping) {
        this.defaultTyping = defaultTyping;
    }

    /**
     * Sets the property naming strategy.
     *
     * @param propertyNamingStrategy The property naming strategy
     */
    public void setPropertyNamingStrategy(PropertyNamingStrategy propertyNamingStrategy) {
        this.propertyNamingStrategy = propertyNamingStrategy;
    }

    /**
     * Sets whether _embedded.errors should always be serialized as list (defaults to false).
     * If set to false, _embedded.errors with 1 element will be serialized as an object.
     *
     * @param alwaysSerializeErrorsAsList True if _embedded.errors should always be serialized as list
     */
    public void setAlwaysSerializeErrorsAsList(boolean alwaysSerializeErrorsAsList) {
        this.alwaysSerializeErrorsAsList = alwaysSerializeErrorsAsList;
    }

    /**
     * Whether strings should be trimmed when deserializing (defaults to false).
     * If the resulting string is an empty string, then null will be applied instead.
     *
     * @param trimStrings True if strings should be trimmed when deserializing
     */
    public void setTrimStrings(boolean trimStrings) {
        this.trimStrings = trimStrings;
    }

    /**
     * Constructors a JavaType for the given argument and type factory.
     *
     * @param type The type
     * @param typeFactory The type factory
     * @param <T> The generic type
     * @return The JavaType
     */
    public static <T> JavaType constructType(@NonNull Argument<T> type, @NonNull TypeFactory typeFactory) {
        ArgumentUtils.requireNonNull("type", type);
        ArgumentUtils.requireNonNull("typeFactory", typeFactory);
        Map<String, Argument<?>> typeVariables = type.getTypeVariables();
        JavaType[] objects = toJavaTypeArray(typeFactory, typeVariables);
        final Class<T> rawType = type.getType();
        if (ArrayUtils.isNotEmpty(objects)) {
            final JavaType javaType = typeFactory.constructType(
                rawType
            );
            if (javaType.isCollectionLikeType()) {
                return typeFactory.constructCollectionLikeType(
                    rawType,
                    objects[0]
                );
            } else if (javaType.isMapLikeType()) {
                return typeFactory.constructMapLikeType(
                    rawType,
                    objects[0],
                    objects[1]
                );
            } else if (javaType.isReferenceType()) {
                return typeFactory.constructReferenceType(rawType, objects[0]);
            }
            return typeFactory.constructParametricType(rawType, objects);
        } else {
            return typeFactory.constructType(
                rawType
            );
        }
    }

    private static JavaType[] toJavaTypeArray(TypeFactory typeFactory, Map<String, Argument<?>> typeVariables) {
        var javaTypes = new ArrayList<JavaType>();
        for (Argument<?> argument : typeVariables.values()) {
            if (argument.hasTypeVariables()) {
                javaTypes.add(typeFactory.constructParametricType(argument.getType(), toJavaTypeArray(typeFactory, argument.getTypeVariables())));
            } else {
                javaTypes.add(typeFactory.constructType(argument.getType()));
            }
        }
        return javaTypes.toArray(new JavaType[0]);
    }
}
